Ticketmaster โ€” System Design Cheatsheet

Search (availability) ยท Booking (consistency, no double-sell) ยท 10M users on 1 event ยท read-heavy 100:1 ยท <500ms search

1. Requirements in one line

Functional

Out of scope

Non-functional (the real design drivers)

The split personality (availability for search, consistency for booking) is the whole interview. Say it up front.

2. Core entities & schema

Tables (Postgres, ACID)

Event    { id, venueId, performerId,
           name, description, date }
Venue    { id, location, seatMap }
Performer{ id, name }
Ticket   { id, eventId, seat, price,
           status: available|reserved|booked,
           userId }
Booking  { id, userId, ticketIds[],
           status: pending|completed|failed }

Why relational / ACID here

Ticket state lives in the DB. Redis holds a short-lived lock, never the truth.

3. API interfaces

Reads (availability path)

GET /events?search_term=&location=&date=
   โ†’ [Event]      (search by any combo)

GET /events/{id}
   โ†’ Event + available tickets

Writes (consistency path)

POST /bookings
   Idempotency-Key: <uuid>
   { eventId, ticketIds: string[] }
   โ†’ { bookingId }        (reserves, TTL)

POST /bookings/{id}/payment
   โ†’ confirmation         (commits sale)

Two-step: reserve first (lock seats), pay second (finalize). Client generates the idempotency key.

4. Architecture

Client CDN LB + Gateway auth rate limit routing Search svc Elasticsearch inverted index Event svc Redis cache hot events Postgres leader (writes) + read replicas ACID ยท sharded by eventId Virtual waiting queue (Redis) Booking svc Ticket Lock {ticketId:userId} TTL 10m Stripe payment popular event CDC commit txn

Two paths. Read path: Client โ†’ CDN โ†’ Gateway โ†’ Search (Elasticsearch) or Event svc (Redis โ†’ replicas). Write path: Gateway โ†’ (waiting queue if hot) โ†’ Booking svc โ†’ Redis lock โ†’ leader Postgres โ†’ Stripe.

5. Search path (availability, <500ms)

How

Why this scales the 100:1 read ratio

6. Elasticsearch & CDC (deep dive)

DB is truth, ES is a derived read model

Postgres --WAL--> CDC (Debezium)
         --> Kafka --> ES upsert(eventId)

ES doesn't replace the Redis cache

ES has internal caches, but none is an app-layer read-through store:

Keep Redis in front for hot event pages / exact-repeat queries: sub-ms, you control the TTL. Different job than ES.

7. Booking path โ€” no double-sell (deep dive)

Reserve โ†’ Pay, with a Redis lock

POST /bookings {eventId, ticketIds}
1. For each ticketId, SET NX in Redis:
     SET lock:{ticketId} {userId}
         NX EX 600          (10-min TTL)
   Multiple tickets โ†’ acquire in sorted
   order (avoid deadlock), atomic.
2. Any SET NX fails โ†’ seat taken โ†’
   release the ones you got โ†’ 409.
3. All locks held โ†’ Booking row
   (status=pending), return bookingId.

POST /bookings/{id}/payment
4. Stripe charge succeeds โ†’
   TXN: tickets.status=booked,
        booking.status=completed,
        delete Redis locks.

Why a Redis lock + TTL

Belt-and-suspenders (defend it)

The final commit re-checks ticket.status='available' inside the transaction (or uses SELECT ... FOR UPDATE / optimistic version column). If Redis ever fails open, the DB still refuses a double-sell. Redis is an optimization for the common case, not the correctness guarantee.

Pick ONE reservation store โ€” this vs ยง11 are alternatives, not a stack

The Redis SET NX + TTL here and the DB SKIP LOCKED in ยง11 both hold a reservation. Don't run both โ€” two stores drift (Redis key expires but the DB row stays reserved forever). The choice follows the seat-selection model:

How the request knows which path to take

8. Idempotency (deep dive)

Problem

Client retries POST /bookings (timeout, double-tap, flaky network). Without protection you reserve/charge twice.

Mechanism

Where it matters most

One line to say: "Every mutating call is safe to retry โ€” client key on POST /bookings, same key forwarded to Stripe for the charge."

9. Replicas & leader-follower (deep dive)

Topology

Trade-offs to name

10. Popular events โ€” the virtual waiting queue (deep dive)

Why

10M users refreshing one event will crush the booking service and Redis locks. You can't lock-check 10M/s. Shed load before it reaches the booking path.

How (Redis)

On event open, gate entry:
  queue:{eventId}   โ†’ sorted set of
                       sessionIds by ts
  admitted:{eventId}โ†’ set of allowed

Admit a steady trickle (e.g. N/sec)
from the front of the queue into the
real booking flow. Everyone else waits
and sees position + ETA.

What it buys you

Only turn the queue on for flagged high-demand events โ€” normal events skip it entirely.

11. Seat assignment by section + quantity (deep dive)

Server picks the seats, not the user

The go-to for popular onsales. User picks a section and a quantity, not specific seats. Server atomically selects and reserves the best available block in one statement โ€” no human between the read and the write, and no Redis. Reservation state lives in the DB.

BEGIN;
SELECT id FROM tickets
  WHERE eventId=? AND section=?
    AND (status='available'
      OR (status='reserved'
          AND reserved_until < now()))
  ORDER BY seat_quality
  LIMIT ?                     -- qty
  FOR UPDATE SKIP LOCKED;     -- key
UPDATE tickets
  SET status='reserved', userId=?,
      reserved_until = now()+'10 min'
  WHERE id IN (...);
COMMIT;

Why it works

Expiry without Redis

The txn is short โ€” pick, flip to reserved, commit. It does not hold the row lock for the 10-min checkout (that would pin a connection). The reserved status is what keeps other pickers off. Abandoned carts are reclaimed lazily (the reserved_until < now() predicate treats them as free) plus a light sweeper for count accuracy. This replaces the Redis TTL from ยง7.

Cost (defend it)

Users give up specific seat choice. Fine for a general-admission-style onsale; wrong for resale or accessible seating, where the user must pick the exact seat โ€” there you fall back to the per-seat Redis lock in ยง7.

12. Live seat map โ€” short-interval polling, not SSE (deep dive)

Poll every 1โ€“2s

For high-demand events, clients poll the seat map every 1โ€“2s rather than holding an SSE/WebSocket connection.

Why polling wins here (defend it)

Use SSE/WebSocket for low-fanout, truly push-driven cases; for a 50k-viewer onsale map, cached polling is cheaper and more robust.

13. Follow-ups โ€” answers to have ready

User reserves a seat then vanishes

The Redis lock has a 10-min TTL; it auto-releases and the seat returns to available. No sweeper job needed. Availability count is recomputed on read (or the count is decremented/incremented as locks flip).

Two users grab the last seat at the same instant

SET NX is atomic โ€” exactly one succeeds and gets the lock. The loser gets 409 immediately. Even if both somehow reached the DB, the commit's FOR UPDATE / status check lets only one flip the row to booked.

Payment succeeds but the booking write fails

Charge Stripe with the idempotency key after (or coordinated with) flipping ticket state, and treat the booking as an idempotent workflow: on retry, the Stripe key prevents a second charge and the DB txn is re-attempted. If it can't complete, refund via the same key. Prefer capturing payment only once seats are durably committed.

Search shows a seat that's already gone

Expected โ€” search/availability is eventually consistent (CDC + cached counts). The reserve step is the authority: the stale view just means an occasional "sorry, just taken" at booking time, which is acceptable for an availability-first read path.

Multiple tickets in one booking (all-or-nothing)

Acquire locks for all ticketIds in a deterministic (sorted) order to avoid deadlock; if any fails, release the rest and fail the whole booking. Commit all ticket rows in one DB transaction.

Redis (lock store) goes down

Booking is the consistency-first path, so fail closed: the DB transaction with row locking is the real guarantee, so worst case we fall back to DB-only reservation (slower, still correct) or briefly reject bookings. Never fail open into a double-sell.

Why not just lock rows in Postgres and skip Redis?

You can, and the DB is the ultimate guarantee. Redis is there to keep long-held "reserved-but-not-paid" state off the leader and to absorb the popular-event spike cheaply. It's a performance layer in front of the correctness layer.

14. Numbers to drop

Load

Read-heavy 100:1. 10M users on one on-sale โ†’ without a queue that's a multi-million/s spike on booking; the waiting queue admits maybe a few thousand/s into the real flow.

Storage / locks

A ticket lock is ~tens of bytes; even a 100k-seat stadium is a few MB in Redis, all TTL'd. Elasticsearch index sized to event catalog, not to traffic.

15. 30-second recap script

Two paths with opposite priorities. Reads favor availability: search hits Elasticsearch (fed by CDC from Postgres), event pages and availability counts are cached in Redis and served from read replicas, so the 100:1 read load never touches the write leader and stays under 500ms. Writes favor consistency: booking is a two-step reserve-then-pay. Reserve does an atomic SET NX Redis lock per seat with a 10-minute TTL so abandoned carts self-release; payment commits a single Postgres transaction that flips ticket status and is the real source of truth โ€” Redis is just a fast reservation in front of it. Every mutation is idempotent via a client UUID key, forwarded to Stripe so retries never double-charge. Postgres is single-leader for writes with read replicas and is sharded by eventId so a megaevent stays on one partition. For a 10M-user on-sale we put a Redis virtual waiting queue in front, admitting a bounded trickle in FIFO order so the booking path never sees the full spike. If Redis dies we fail closed to the DB's row locks โ€” never into a double-sell.